@codeyam-editor/codeyam-editor 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud.js +192 -29
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +123 -14
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-memory.js +44 -0
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +37 -7
- package/npm/launch-config.js +120 -4
- package/npm/scenario-check.js +52 -18
- package/npm/scenario-playwright.js +44 -10
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-DCHqRrwO.js → index-VuyqBbGJ.js} +2 -2
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
package/npm/cloud.js
CHANGED
|
@@ -105,6 +105,10 @@ const {
|
|
|
105
105
|
// edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
|
|
106
106
|
// fleet for `reconcile-ingress`.
|
|
107
107
|
const { addToRoster, readRoster } = require("./fleet-roster");
|
|
108
|
+
// Tells a docker container-name conflict (a concurrent run already started this
|
|
109
|
+
// container) apart from a real `docker compose up -d` failure, so the abort path
|
|
110
|
+
// can converge instead of escalating to instance deletion.
|
|
111
|
+
const { classifyComposeUpFailure } = require("./compose-conflict");
|
|
108
112
|
|
|
109
113
|
// =============================================================================
|
|
110
114
|
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
@@ -845,6 +849,79 @@ function parseEditorComposeState(stdout) {
|
|
|
845
849
|
return null;
|
|
846
850
|
}
|
|
847
851
|
|
|
852
|
+
/** Decide whether `cloud:up`'s abort handler may delete the instance it is
|
|
853
|
+
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
|
|
854
|
+
* → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
855
|
+
* `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
|
|
856
|
+
* exhaustively testable without a VM.
|
|
857
|
+
*
|
|
858
|
+
* `createdThisRun` stays a NECESSARY condition — never delete an instance this
|
|
859
|
+
* run did not create — but 2026-08-07 proved it is not SUFFICIENT. It answers
|
|
860
|
+
* "did I create this?", not "is it safe to destroy?", and in a concurrent
|
|
861
|
+
* provision race the losing run legitimately owns the flag while the winner has
|
|
862
|
+
* already brought the VM up and rostered it. Deleting there destroyed VM-4 and
|
|
863
|
+
* VM-5. So authorization also requires CURRENT-STATE evidence that the VM is
|
|
864
|
+
* out of service:
|
|
865
|
+
*
|
|
866
|
+
* - `containerUp` — the editor container is running (per the compose-ps probe).
|
|
867
|
+
* A VM serving an editor is in use by definition.
|
|
868
|
+
* - `rostered` — the VM is in the fleet roster, the operator-visible source of
|
|
869
|
+
* truth. Note this is true whether the winning run or THIS run rostered it;
|
|
870
|
+
* either way the VM is visible in the dashboard and removable in one command,
|
|
871
|
+
* so it is not the silent orphan teardown exists to prevent.
|
|
872
|
+
* - `probeInconclusive` — liveness could not be determined. This must fail
|
|
873
|
+
* SAFE: a failing probe is how we reach the abort handler in the first place,
|
|
874
|
+
* so it can never count as evidence the VM is dead. An orphan costs one
|
|
875
|
+
* `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
|
|
876
|
+
* any unpushed work on the VM. */
|
|
877
|
+
function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
|
|
878
|
+
if (!createdThisRun) return "keep";
|
|
879
|
+
if (probeInconclusive) return "keep";
|
|
880
|
+
if (containerUp) return "keep";
|
|
881
|
+
if (rostered) return "keep";
|
|
882
|
+
return "delete";
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
|
|
886
|
+
* abort handler reads gather → decide → act instead of inlining probes among
|
|
887
|
+
* the delete calls.
|
|
888
|
+
*
|
|
889
|
+
* Only runs the probes when `createdThisRun` is true — the decision is `keep`
|
|
890
|
+
* regardless otherwise, and an abort path should not spend SSH round trips
|
|
891
|
+
* proving something it already knows. In that case it returns the
|
|
892
|
+
* probe-inconclusive shape, which is the safe default at every layer.
|
|
893
|
+
*
|
|
894
|
+
* Both lookups are wrapped: a throwing probe or an unreadable roster must fail
|
|
895
|
+
* SAFE (toward `keep`), never take a second exception path into the delete.
|
|
896
|
+
* `deps` is injectable so the matrix is unit-tested without a VM. */
|
|
897
|
+
function probeAbortTeardownState(cfg, createdThisRun, deps = {}) {
|
|
898
|
+
if (!createdThisRun) {
|
|
899
|
+
return { containerUp: false, rostered: false, probeInconclusive: true };
|
|
900
|
+
}
|
|
901
|
+
const probe = deps.probeEditorComposeState || probeEditorComposeState;
|
|
902
|
+
const roster = deps.readRoster || readRoster;
|
|
903
|
+
const resolveVm = deps.resolveFleetVmNumber || resolveFleetVmNumber;
|
|
904
|
+
const log = deps.log || ((m) => console.error(m));
|
|
905
|
+
|
|
906
|
+
let liveness = { state: null, inconclusive: true };
|
|
907
|
+
try {
|
|
908
|
+
liveness = probe(cfg);
|
|
909
|
+
} catch (e) {
|
|
910
|
+
log(` (liveness probe failed: ${e && e.message})`);
|
|
911
|
+
}
|
|
912
|
+
let rostered = false;
|
|
913
|
+
try {
|
|
914
|
+
const vm = resolveVm(cfg.instanceName);
|
|
915
|
+
rostered = vm != null && (roster() || []).includes(vm);
|
|
916
|
+
} catch { /* unreadable roster — treated as not-rostered; liveness still guards */ }
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
containerUp: liveness.state === "running",
|
|
920
|
+
rostered,
|
|
921
|
+
probeInconclusive: liveness.inconclusive,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
848
925
|
/** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
|
|
849
926
|
* switch's job is to keep every per-verb command shape in one reviewable
|
|
850
927
|
* place — tests assert the arrays without ever spawning a process. */
|
|
@@ -2049,20 +2126,65 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2049
2126
|
}
|
|
2050
2127
|
}
|
|
2051
2128
|
|
|
2052
|
-
|
|
2129
|
+
/** Run the compose-ps liveness probe over SSH and report BOTH the editor
|
|
2130
|
+
* service's state and whether the probe itself could be trusted.
|
|
2131
|
+
*
|
|
2132
|
+
* The `|| true` keeps the remote pipeline's exit status 0 whenever SSH itself
|
|
2133
|
+
* worked, so a non-zero `status` means the transport failed (auth/SSH/IAP) —
|
|
2134
|
+
* not that the container is down. Callers that must fail safe (the abort
|
|
2135
|
+
* teardown) need that distinction; `parseEditorComposeState` alone collapses
|
|
2136
|
+
* "no container" and "could not ask" into the same `null`.
|
|
2137
|
+
*
|
|
2138
|
+
* Returns `{ state, inconclusive }` where `state` is the compose `State` string
|
|
2139
|
+
* (`"running"`, `"exited"`, …) or null when the service is absent, and
|
|
2140
|
+
* `inconclusive` is true only for a transport failure. */
|
|
2141
|
+
function probeEditorComposeState(cfg) {
|
|
2053
2142
|
const repoName = resolveProject(cfg).name;
|
|
2054
|
-
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
2055
|
-
// docker-compose.yml does `build: .` from source and would shadow the
|
|
2056
|
-
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
2057
2143
|
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2058
|
-
console.error("Checking editor container state...");
|
|
2059
2144
|
const probe = runGcloud(
|
|
2060
2145
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2061
|
-
command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2146
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2062
2147
|
}),
|
|
2063
2148
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2064
2149
|
);
|
|
2065
|
-
if (probe.status
|
|
2150
|
+
if (probe.status !== 0) {
|
|
2151
|
+
return { state: null, inconclusive: true };
|
|
2152
|
+
}
|
|
2153
|
+
return { state: parseEditorComposeState(probe.stdout), inconclusive: false };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/** Poll {@link probeEditorComposeState} until the editor service reads
|
|
2157
|
+
* `running`, returning true as soon as it does. Used to close the TOCTOU window
|
|
2158
|
+
* after a container-name conflict: the winning run may still be inside
|
|
2159
|
+
* `Creating`/`Created` when the conflict surfaces, so a single immediate probe
|
|
2160
|
+
* can under-read and report a healthy container as absent.
|
|
2161
|
+
*
|
|
2162
|
+
* Deliberately short (default ~30s): this is not the multi-minute first-boot
|
|
2163
|
+
* budget `waitForContainerRunning` grants, it is the tail of a start that
|
|
2164
|
+
* another process has already begun. `sleepFn` and `probeFn` are injectable so
|
|
2165
|
+
* a unit test drives the whole loop with neither wall-clock delay nor a VM. */
|
|
2166
|
+
async function waitForEditorComposeRunning(cfg, { attempts = 6, delayMs = 5000, sleepFn, probeFn } = {}) {
|
|
2167
|
+
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2168
|
+
const probe = probeFn || probeEditorComposeState;
|
|
2169
|
+
for (let i = 0; i < attempts; i++) {
|
|
2170
|
+
if (probe(cfg).state === "running") {
|
|
2171
|
+
return true;
|
|
2172
|
+
}
|
|
2173
|
+
if (i < attempts - 1) {
|
|
2174
|
+
await sleep(delayMs);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
async function ensureEditorContainerUp(cfg) {
|
|
2181
|
+
const repoName = resolveProject(cfg).name;
|
|
2182
|
+
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
2183
|
+
// docker-compose.yml does `build: .` from source and would shadow the
|
|
2184
|
+
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
2185
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2186
|
+
console.error("Checking editor container state...");
|
|
2187
|
+
if (probeEditorComposeState(cfg).state === "running") {
|
|
2066
2188
|
console.error(" ✓ editor container is already running");
|
|
2067
2189
|
// containerStarted: the container is confirmed up, so the CLI install runs
|
|
2068
2190
|
// its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
|
|
@@ -2070,14 +2192,47 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2070
2192
|
return;
|
|
2071
2193
|
}
|
|
2072
2194
|
console.error(" starting editor container (docker compose up -d)...");
|
|
2195
|
+
// CAPTURE rather than `stdio: "inherit"`: the daemon writes its container-name
|
|
2196
|
+
// conflict to stderr, and with "inherit" that text went straight to our parent
|
|
2197
|
+
// and was never observable here — so the conflict could not be told apart from
|
|
2198
|
+
// a real failure (the 2026-08-07 VM-4/VM-5 delete). spawnSync cannot both
|
|
2199
|
+
// stream and capture, so the operator's view is preserved by echoing the
|
|
2200
|
+
// captured streams immediately below instead of live. Do NOT revert this to
|
|
2201
|
+
// "inherit"; classification depends on it.
|
|
2073
2202
|
const up = runGcloud(
|
|
2074
2203
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2075
2204
|
command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
|
|
2076
2205
|
}),
|
|
2077
|
-
{ stdio: "
|
|
2206
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
2078
2207
|
);
|
|
2208
|
+
if (up.stdout) process.stderr.write(up.stdout);
|
|
2209
|
+
if (up.stderr) process.stderr.write(up.stderr);
|
|
2079
2210
|
if (up.status !== 0) {
|
|
2080
|
-
|
|
2211
|
+
const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
|
|
2212
|
+
if (verdict !== "name-conflict") {
|
|
2213
|
+
throw new Error("docker compose up -d failed on the VM");
|
|
2214
|
+
}
|
|
2215
|
+
// A name conflict means a concurrent run already created this container —
|
|
2216
|
+
// the goal we were about to pursue is being reached by someone else. Confirm
|
|
2217
|
+
// that positively before converging: a conflict whose container never
|
|
2218
|
+
// reaches `running` (e.g. a stale STOPPED container squatting the name) is a
|
|
2219
|
+
// genuine problem and must still fail loudly, or convergence becomes a
|
|
2220
|
+
// catch-all that papers over real breakage.
|
|
2221
|
+
console.error(
|
|
2222
|
+
" a concurrent run already created the editor container — confirming it reaches running...",
|
|
2223
|
+
);
|
|
2224
|
+
if (!(await waitForEditorComposeRunning(cfg))) {
|
|
2225
|
+
throw new Error(
|
|
2226
|
+
"docker compose up -d hit a container-name conflict on the VM, but the conflicting " +
|
|
2227
|
+
"container never reached running (a stale stopped container may be holding the name). " +
|
|
2228
|
+
`Inspect with: gcloud compute ssh ${cfg.instanceName} --command 'cd ~/projects/${repoName} && ${composeCmd} ps -a'`,
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
console.error(" ✓ editor container is running (started by a concurrent run)");
|
|
2232
|
+
// containerStarted: the container is confirmed up — same post-start contract
|
|
2233
|
+
// as the already-running short-circuit above.
|
|
2234
|
+
await ensureAgentCliInstalled(cfg, { containerStarted: true });
|
|
2235
|
+
return;
|
|
2081
2236
|
}
|
|
2082
2237
|
// containerStarted: `up -d` returned 0, so the CLI install is legitimately a
|
|
2083
2238
|
// post-start step. A brief wait-for-running here (slow boot settling) is
|
|
@@ -2093,18 +2248,10 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2093
2248
|
// success. This throws (non-zero exit) when the editor service isn't
|
|
2094
2249
|
// `running`, converting a silent partial provision into a loud, actionable
|
|
2095
2250
|
// failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
|
|
2096
|
-
// on.
|
|
2097
|
-
//
|
|
2251
|
+
// on. An inconclusive probe is not "running" here — a terminal gate must have
|
|
2252
|
+
// positive evidence, and the caller's job is to fail loudly without it.
|
|
2098
2253
|
async function assertEditorContainerRunning(cfg) {
|
|
2099
|
-
|
|
2100
|
-
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2101
|
-
const probe = runGcloud(
|
|
2102
|
-
buildGcloudArgs("ssh-probe", cfg, {
|
|
2103
|
-
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2104
|
-
}),
|
|
2105
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2106
|
-
);
|
|
2107
|
-
if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
|
|
2254
|
+
if (probeEditorComposeState(cfg).state !== "running") {
|
|
2108
2255
|
throw new Error(
|
|
2109
2256
|
`provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
|
|
2110
2257
|
`(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
|
|
@@ -4122,7 +4269,27 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4122
4269
|
);
|
|
4123
4270
|
}
|
|
4124
4271
|
} catch (err) {
|
|
4125
|
-
|
|
4272
|
+
// `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
|
|
4273
|
+
// VM is in right now, then let the pure decision rule — see
|
|
4274
|
+
// abortTeardownDecision's doc comment for why each input is load-bearing.
|
|
4275
|
+
const state = probeAbortTeardownState(cfg, createdThisRun);
|
|
4276
|
+
const verdict = abortTeardownDecision({ createdThisRun, ...state });
|
|
4277
|
+
if (createdThisRun && verdict === "keep") {
|
|
4278
|
+
// Deliberately loud and specific. The old blanket "tearing down the VM
|
|
4279
|
+
// cloud:up created this run (no orphan left behind)" was actively
|
|
4280
|
+
// misleading here — it asserted no orphan while deleting a working VM.
|
|
4281
|
+
const why = state.probeInconclusive
|
|
4282
|
+
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
4283
|
+
: state.containerUp
|
|
4284
|
+
? "its editor container is RUNNING — another run brought it up"
|
|
4285
|
+
: "it is in the fleet roster and visible to the dashboard";
|
|
4286
|
+
console.error(
|
|
4287
|
+
`✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
|
|
4288
|
+
` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
|
|
4289
|
+
` If it really should go, remove it deliberately: npm run cloud:destroy`,
|
|
4290
|
+
);
|
|
4291
|
+
}
|
|
4292
|
+
if (verdict === "delete") {
|
|
4126
4293
|
console.error(
|
|
4127
4294
|
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
4128
4295
|
);
|
|
@@ -4304,14 +4471,7 @@ async function cmdStatus(args, projectDir = rootDir) {
|
|
|
4304
4471
|
const imgStatus = String(imgProbe.stdout || "").trim() || "?";
|
|
4305
4472
|
console.error(` image: ${imgStatus}`);
|
|
4306
4473
|
|
|
4307
|
-
const
|
|
4308
|
-
const containerProbe = runGcloud(
|
|
4309
|
-
buildGcloudArgs("ssh-probe", cfg, {
|
|
4310
|
-
command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
|
|
4311
|
-
}),
|
|
4312
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
4313
|
-
);
|
|
4314
|
-
const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
|
|
4474
|
+
const containerStatus = probeEditorComposeState(cfg).state || "missing";
|
|
4315
4475
|
console.error(` editor container: ${containerStatus}`);
|
|
4316
4476
|
|
|
4317
4477
|
const appPort = await readVmAppPort(cfg);
|
|
@@ -5058,6 +5218,9 @@ module.exports = {
|
|
|
5058
5218
|
assertAgentCliInstalled,
|
|
5059
5219
|
probeContainerRunning,
|
|
5060
5220
|
probeContainerState,
|
|
5221
|
+
waitForEditorComposeRunning,
|
|
5222
|
+
abortTeardownDecision,
|
|
5223
|
+
probeAbortTeardownState,
|
|
5061
5224
|
probeAgentBinaryInstalled,
|
|
5062
5225
|
waitForContainerRunning,
|
|
5063
5226
|
KNOWN_FLAGS,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/compose-conflict.js — classify a failed `docker compose up -d` on a fleet
|
|
4
|
+
// VM, so `cloud:up` can tell "another run beat me to this container" apart from
|
|
5
|
+
// "provisioning genuinely broke".
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: on 2026-08-07 two concurrent `cloud:up` runs raced to start
|
|
8
|
+
// the editor container on one fresh VM. The loser's `up -d` exited non-zero with
|
|
9
|
+
// the docker daemon's container-name conflict, `cloud.js` flattened that into a
|
|
10
|
+
// generic "docker compose up -d failed on the VM", and the abort handler
|
|
11
|
+
// escalated it all the way to deleting a VM the winner had already brought up
|
|
12
|
+
// and rostered. A name conflict is the opposite of a failure — it is proof the
|
|
13
|
+
// shared goal (container running) was reached by someone else.
|
|
14
|
+
//
|
|
15
|
+
// Matching a daemon error string is exactly the kind of thing that rots when
|
|
16
|
+
// docker rephrases it, so it lives here as a named, unit-tested function with
|
|
17
|
+
// the real observed message pinned as a fixture — not as an inline regex at the
|
|
18
|
+
// call site.
|
|
19
|
+
|
|
20
|
+
// The stable fragments of the daemon's conflict message. The container id and
|
|
21
|
+
// name vary per run and docker's exact phrasing has changed across versions, so
|
|
22
|
+
// we require all three fragments rather than the whole sentence. Observed
|
|
23
|
+
// verbatim in the VM-4 add log:
|
|
24
|
+
//
|
|
25
|
+
// Error response from daemon: Conflict. The container name
|
|
26
|
+
// "/codeyam-editor-editor-1" is already in use by container "c9d448c6…".
|
|
27
|
+
// You have to remove (or rename) that container to be able to reuse that name.
|
|
28
|
+
//
|
|
29
|
+
// Compose emits it twice (once prefixed `service:editor:1`, once bare) and
|
|
30
|
+
// interleaves its own progress lines, so the match is substring-based over the
|
|
31
|
+
// whole captured stream rather than line- or anchor-based.
|
|
32
|
+
const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
|
|
33
|
+
|
|
34
|
+
/** Classify the outcome of `docker compose up -d`.
|
|
35
|
+
*
|
|
36
|
+
* Returns one of:
|
|
37
|
+
* - `'ok'` — the command succeeded; there is no failure to classify. Guards
|
|
38
|
+
* against a caller that classifies unconditionally, so a zero status can
|
|
39
|
+
* never be read as a conflict.
|
|
40
|
+
* - `'name-conflict'` — the daemon refused because a container of that name
|
|
41
|
+
* already exists, i.e. a concurrent run already started it. The caller
|
|
42
|
+
* re-probes for a running container and converges instead of throwing.
|
|
43
|
+
* - `'failed'` — anything else. Existing behavior: a real provisioning
|
|
44
|
+
* failure.
|
|
45
|
+
*
|
|
46
|
+
* Pure: `{ status, stderr } → tag`, with no I/O, so the whole matrix is
|
|
47
|
+
* testable without a VM. `stderr` may be null/undefined (the pre-fix call site
|
|
48
|
+
* captured nothing), which classifies as `'failed'` — the conservative
|
|
49
|
+
* direction, since converging requires positive evidence of a conflict. */
|
|
50
|
+
function classifyComposeUpFailure({ status, stderr } = {}) {
|
|
51
|
+
if (status === 0) return 'ok';
|
|
52
|
+
const text = String(stderr == null ? '' : stderr);
|
|
53
|
+
if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
|
|
54
|
+
return 'name-conflict';
|
|
55
|
+
}
|
|
56
|
+
return 'failed';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { classifyComposeUpFailure };
|
package/npm/fleet-add-failure.js
CHANGED
|
@@ -96,6 +96,67 @@ function transcriptRostered(logTail) {
|
|
|
96
96
|
return parseRosterMarkers(String(logTail || '')).length > 0;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// The categories that describe an add whose outcome is NOT yet proven bad — the
|
|
100
|
+
// calm, in-flight side of the classifier. `converging` is a rostered box whose
|
|
101
|
+
// ingress is still wiring; `verifying` is the residue: no failure signature
|
|
102
|
+
// matched and nothing durable proves the VM is dead. Both render a calm card and
|
|
103
|
+
// neither may use the word "failed". Consumed by holdConvergingBadge and by
|
|
104
|
+
// deriveStatus (server.js) to pick the matching calm status.
|
|
105
|
+
const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying']);
|
|
106
|
+
|
|
107
|
+
// Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
|
|
108
|
+
// positive proof of death, rather than merely an absence of proof of life?
|
|
109
|
+
//
|
|
110
|
+
// This is the conjunction that gates the red `unknown` verdict. Before it, the
|
|
111
|
+
// classifier's fallthrough treated "no recognized success signature" as failure,
|
|
112
|
+
// so "we don't know" rendered as a terminal red "Add failed" telling the operator
|
|
113
|
+
// to destroy a VM whose container was Up and whose editor answered /api/health
|
|
114
|
+
// 200 (VM-4, 2026-07-31). Red now requires BOTH halves:
|
|
115
|
+
//
|
|
116
|
+
// 1. A non-zero exit code. A *missing* code is not proof of death — today it
|
|
117
|
+
// usually means the wrapper was signal-killed while its provision ran on,
|
|
118
|
+
// which is exactly the case that was misreading as failure. `null`,
|
|
119
|
+
// `undefined`, a non-integer, and 0 all fail this half.
|
|
120
|
+
// 2. At least one durable corroborating fact:
|
|
121
|
+
// - `instanceAlive === false` GCP says the instance is gone.
|
|
122
|
+
// - `containerHealthy === false` the in-VM editor was probed and is not
|
|
123
|
+
// answering — the box exists but never came up.
|
|
124
|
+
// - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
|
|
125
|
+
// marker is the tail's own honest deadline signal).
|
|
126
|
+
//
|
|
127
|
+
// Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
|
|
128
|
+
// corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
|
|
129
|
+
// never be escalated to red by absence of evidence.
|
|
130
|
+
function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
|
|
131
|
+
const code = exitCode == null ? NaN : Number(exitCode);
|
|
132
|
+
if (!Number.isInteger(code) || code === 0) return false;
|
|
133
|
+
return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Resolve the two independent health signals into the single tri-state
|
|
137
|
+
// `containerHealthy` fact addFailureCorroborated consumes. Pure so the precedence
|
|
138
|
+
// — which decides whether a VM can be escalated to red — is unit-tested; the
|
|
139
|
+
// server (addFailureFacts) supplies the live signals.
|
|
140
|
+
//
|
|
141
|
+
// reachable the editor answered over the LB.
|
|
142
|
+
// inVmHealthy the STRAND_PROBE /api/health read from INSIDE the container.
|
|
143
|
+
//
|
|
144
|
+
// LB reachability wins when present: it is the stronger witness (it proves the
|
|
145
|
+
// whole URL→ingress→backend path, not just the process). Otherwise the in-VM
|
|
146
|
+
// witness answers — this is the still-wiring case, where the LB necessarily
|
|
147
|
+
// cannot see a VM whose ingress is what we are waiting on, yet the editor itself
|
|
148
|
+
// is demonstrably up.
|
|
149
|
+
//
|
|
150
|
+
// Returns `null` when NEITHER signal was gathered. That is the load-bearing case:
|
|
151
|
+
// an unprobed VM must corroborate nothing, so it can never be escalated to red by
|
|
152
|
+
// absence of evidence. `false` is reserved for a VM that was genuinely probed and
|
|
153
|
+
// did not answer.
|
|
154
|
+
function containerHealthWitness({ reachable, inVmHealthy } = {}) {
|
|
155
|
+
if (reachable) return true;
|
|
156
|
+
if (inVmHealthy != null) return !!inVmHealthy;
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
99
160
|
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
100
161
|
// can never drift from the category.
|
|
101
162
|
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
@@ -108,7 +169,16 @@ function buildFailureVerdict(category, headline, recovery, extra) {
|
|
|
108
169
|
//
|
|
109
170
|
// logTail the last lines of the provision transcript (string; '' when the
|
|
110
171
|
// log was unreadable — must never throw)
|
|
111
|
-
// exitCode the provision's exit code (number; used for signal-kill detection
|
|
172
|
+
// exitCode the provision's exit code (number; used for signal-kill detection
|
|
173
|
+
// and as the first half of the proof-of-death conjunction)
|
|
174
|
+
//
|
|
175
|
+
// The remaining inputs are the durable facts the server gathers (the module
|
|
176
|
+
// stays pure — it never probes). All are tri-state; omit any that was not
|
|
177
|
+
// probed and it corroborates nothing. See addFailureCorroborated.
|
|
178
|
+
//
|
|
179
|
+
// instanceAlive does GCP still list the instance?
|
|
180
|
+
// containerHealthy did the in-VM editor answer /api/health?
|
|
181
|
+
// wiringDeadlineBlown did the add-time wire tail drop its failed marker?
|
|
112
182
|
//
|
|
113
183
|
// Returns { category, headline, recovery, retryable, files? }.
|
|
114
184
|
//
|
|
@@ -118,7 +188,7 @@ function buildFailureVerdict(category, headline, recovery, extra) {
|
|
|
118
188
|
// signature is therefore tested BEFORE the generic cloud-up-failed signature, or
|
|
119
189
|
// a retryable drift would misclassify as a non-retryable cloud:up failure and the
|
|
120
190
|
// operator would lose the safe one-click recovery.
|
|
121
|
-
function classifyAddFailure({ logTail, exitCode } = {}) {
|
|
191
|
+
function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
|
|
122
192
|
const log = String(logTail || '');
|
|
123
193
|
|
|
124
194
|
// 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
|
|
@@ -218,9 +288,34 @@ function classifyAddFailure({ logTail, exitCode } = {}) {
|
|
|
218
288
|
'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
|
|
219
289
|
}
|
|
220
290
|
|
|
221
|
-
// 9.
|
|
222
|
-
|
|
223
|
-
|
|
291
|
+
// 9. The residue: no failure signature above matched. The polarity here is
|
|
292
|
+
// INVERTED from the original design. It used to read "no recognized success
|
|
293
|
+
// signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
|
|
294
|
+
// VM whose container was Up and whose editor answered /api/health 200 got a
|
|
295
|
+
// terminal red "Add failed" card advising the operator to destroy it (VM-4,
|
|
296
|
+
// 2026-07-31; the wrapper had been killed mid-flight, so its transcript was
|
|
297
|
+
// truncated before CY_ROSTER and it never reached the `converging` arm above).
|
|
298
|
+
//
|
|
299
|
+
// Red now requires positive proof of death, and everything else is in-flight:
|
|
300
|
+
//
|
|
301
|
+
// 9a. unknown — a non-zero exit CORROBORATED by a durable fact (see
|
|
302
|
+
// addFailureCorroborated). The box is provably dead or provably never
|
|
303
|
+
// came up, so the loud, terminal escalation is warranted and unchanged.
|
|
304
|
+
// Still never invites an unproven retry.
|
|
305
|
+
if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown })) {
|
|
306
|
+
return buildFailureVerdict('unknown', 'Add failed',
|
|
307
|
+
'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 9b. verifying — the honest default. No failure signature, and nothing
|
|
311
|
+
// durable proves this VM is dead: it may still be provisioning or
|
|
312
|
+
// wiring, and the ingress can keep propagating for minutes after the
|
|
313
|
+
// wrapper is gone. Calm copy, transcript still one click away. NOT
|
|
314
|
+
// retryable — nothing is proven about the box either way, so a one-click
|
|
315
|
+
// retry could still duplicate a live GCP instance (the ADD_FAILURE_RETRYABLE
|
|
316
|
+
// grounds are unchanged by this inversion).
|
|
317
|
+
return buildFailureVerdict('verifying', 'Add outcome unconfirmed',
|
|
318
|
+
'No failure signature was found in the transcript and nothing proves this VM is dead, so the add may still be converging — the ingress keeps propagating for minutes after the provision wrapper exits. Inspect the transcript below; reconcile this VM only if it is still unresolved past the wiring deadline.');
|
|
224
319
|
}
|
|
225
320
|
|
|
226
321
|
// Pure guard for the operator "Retry add" action: given a VM number and its
|
|
@@ -238,15 +333,29 @@ function canRetryAdd({ n, job } = {}) {
|
|
|
238
333
|
}
|
|
239
334
|
|
|
240
335
|
// Pure badge-hold predicate for deriveStatus (server.js): hold the calm in-flight
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
336
|
+
// card — instead of the red add-failed — while the add is still converging.
|
|
337
|
+
//
|
|
338
|
+
// The hold used to require `addTailInflight`, i.e. that the detached wire-add-tail
|
|
339
|
+
// PROCESS was still alive. That premise was wrong: the tail's exit says nothing
|
|
340
|
+
// about LB convergence, because the EXTERNAL_MANAGED backend services keep
|
|
341
|
+
// propagating for minutes afterwards. VM-4's backends were still being created at
|
|
342
|
+
// 18:17-18:20Z, long after its job had been declared failed at 18:15Z — the tail
|
|
343
|
+
// had exited, so the calm hold released and the card went red on a healthy VM.
|
|
344
|
+
//
|
|
345
|
+
// The hold is now driven by the question that actually matters: is this add still
|
|
346
|
+
// converging, and has its wiring deadline genuinely blown? `wiringDeadlineBlown`
|
|
347
|
+
// (the failed marker the tail drops when it gives up) stays the release signal —
|
|
348
|
+
// tail liveness is no longer a requirement for calm, so the calm state survives
|
|
349
|
+
// the multi-minute propagation window instead of ending at process exit.
|
|
350
|
+
//
|
|
351
|
+
// Both calm categories qualify (see CALM_ADD_CATEGORIES): a rostered box that is
|
|
352
|
+
// still wiring, and the unproven residue. Any proven-failure category returns
|
|
353
|
+
// false and falls through to the red add-failed status — the operator-chosen
|
|
354
|
+
// escalation, which this predicate narrows but never softens. Kept pure (like
|
|
355
|
+
// canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
|
|
247
356
|
// live dashboard.
|
|
248
|
-
function holdConvergingBadge({ category,
|
|
249
|
-
return category
|
|
357
|
+
function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
|
|
358
|
+
return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
|
|
250
359
|
}
|
|
251
360
|
|
|
252
|
-
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, ADD_FAILURE_RETRYABLE };
|
|
361
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
|